Repository navigation
Conversation
|
Reproduced on bun 1.4.3-canary.1. A header call leaves fields in the HPACK encoder table that the peer never receives, in four ways:
With this branch (41545e3) all four decode right. The 62 rows of the new Status: the branch is restructured after a self-review. The |
|
Updated 1:23 AM PT - Oct 9th, 2026
✅ @robobun, your commit 41545e3578e00fb7126ac3b78da0e8251b393dab passed in 🧪 To try this PR locally: bunx bun-pr 41520That installs a local version of the PR into your bun-41520 --bun |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
src/js/node/http2.ts— nit: removing theprocess.nextTick(emitErrorNT, ...)call inClientHttp2Session#request()'s catch block leavesemitErrorNT(line 447) with no callers in this file — it is now dead code. Fix: delete theemitErrorNTfunction in the same PR (REVIEW.md: "Delete dead code in the same PR that makes it dead… helpers whose last caller you rewired").Extended reasoning...
Grep of
emitErrorNTin src/js/node/http2.ts after the diff returns only line 447 (the definition); the sole call site at the old line 6269 was removed by this change. The identically-named helper insrc/js/internal/streams/destroy.tsis a separate module-local function with a different signature and does not reference this one. Nothing else in the module (or the codebase) imports or calls http2.ts's three-argumentemitErrorNT, so the function is unreachable after merge. REVIEW.md lists dead-code deletion as required scope for the PR that orphans the helper.Verification: nit — The diff removes the sole call site:
- process.nextTick(emitErrorNT, this, e, this.#connections === 0 && this.#closed);at src/js/node/http2.ts:6269 (base). After the change, grep ofemitErrorNTin src/js/node/http2.ts returns only line 447, the definitionfunction emitErrorNT(self: any, error: any, destroy: boolean) { ... }. This is a module-local (non-exported) function with…
|
Both review findings are addressed in 0bffe36: Also ran node's |
|
#41614 is stacked on this branch. It adds node's value semantics (undefined skipped, null stringified, symbol keys never on the wire) on top of the HeaderList change here, and removes the "null value" row from the poison matrix because null is no longer a throw. |
f7dc184 to
90f334a
Compare
The cork buffer is shared by every session on the thread. cork() flushes the session that owns it through that session's transport. When the transport is a JS Duplex, its _write can make a header call on the session that is taking the buffer over. cork() then replaced the owner and reset the offset, so the frames of that call were dropped while their fields stayed in the HPACK table. A block that was encoded before the hand-over also went out behind a block that was encoded after it. cork() now reads the slot again after each forced uncork and keeps what a re-entrant call corked. The header encode takes the cork before it encodes, so no JS runs between the encode and the last byte of the block. A native socket on top of a JS Duplex goes through the unit-assembling write, like a session with no native socket, so a header block larger than the cork is handed over whole.
The HPACK encoder refuses a field whose name plus value is over 65536 bytes. The walks encoded field by field, so a block with such a field left its earlier fields in the dynamic table. The call does not throw: the session error arrives from the event loop, and header blocks that other streams wrote in the same tick went out with shifted indices. lshpack_wrapper_encode_block checks every field of a block before the first one reaches the encoder, then encodes the staged bytes in place. HPACK::encode_block is the only encode the runtime crate can call: HPACK::encode is crate-private to bun_http now. The walks stage into a per-VM scratch (RareData), so a header call makes no allocation once the scratch is warm. The h2 engine stages and encodes the same way. request() checks maxSendHeaderBlockLength on the pre-compression bound. A refused server response now resets its stream with FRAME_SIZE_ERROR, as node does, and a refused 1xx block leaves the stream open. Before, the peer got no frame for it.
90f334a to
57fa470
Compare
…s header block senders Shorten the comments the header block encode added.
…test node closes the session after a frame error, so the 1xx case gets a session of its own, and the test no longer asks for a later request on the same session.
clippy::chunks_exact_to_as_chunks rejects chunks_exact with a constant size.
Take the cork() hand-over loop, the cork before the encode and the write() routing for Duplex-backed sockets out again. cork() and write() are as on main. additionalHeaders() marks its block as informational with an argument. A refused block of that call leaves the stream open for respond(). Every other refused server block resets its stream. The :status check is gone. The engine's send_header_block() and send_push_promise() take the HeaderBlock. Connection keeps no staged block, so H2FrameParser has the size it has on main. Tests: remove the cork hand-over rows. Add rows for a header call made from a header value's toString(), for a block refused after the field walk, for a request queued before connect, and for a session closed inside a failed request(). The encoder refusal rows raise maxSendHeaderBlockLength.
…k.test.ts node-http2.test.js is as on main again. The new file is TypeScript and type-checks under test/tsconfig.json.
…alidate-headers-before-encode
Problem
node:http2header call that fails part-way leaves its earlier fields in the HPACK encoder table, unsent. Later blocks decode against shifted entries: another stream'sset-cookie, orERR_HTTP2_ERROR Protocol error.send_trailers,push_promiseandrequest(src/runtime/api/bun/h2_frame_parser.rs) encoded field by field. A later field could fail validation or exceed the encoder's 65536 bytes.Fix
lshpack_wrapper_encode_blockchecks every field, then encodes the block.bun_runtimehas no per-field encode.test/js/node/http2/node-http2-header-block.test.ts(62 rows, 59 fail on main),test/js/node/http2/, node's 256test-http2-*.js.Background
maxSendHeaderBlockLengthon a bound for the uncompressed block. A check after the encode cannot refuse: the fields are in the table.Downsides
maxSendHeaderBlockLengthcounts the uncompressed bound, like node: a block that fits only when compressed is refused. A refused response resets its stream (FRAME_SIZE_ERROR).mi_mallocless, +1.4% instructions (28,333 against 27,947, 7-fieldrespond()). Per VM: +72 bytes, up to 128 KiB kept..text: +3,072 bytes.Notes
How a block failed after its first fields were in the table
undefined,null, aSymbol, atoStringthat throws. A usertry/catchis not needed for this:pushStream()and a request that was queued before'connect'catch the native throw themselves.request()refuses the block after the field walk: an option check, the session memory limit,maxSendHeaderBlockLength. On main these checks ran after the encode.toString()makes another header call on the same session. That block went out ahead of the fields that the first call had already encoded.Repro for the first way (bun 1.4.3, bun client and bun server):
Before:
After:
This bug has no user report. An automated fuzz run found it. The table invariant itself has precedent: #31323 fixed it on the decode side, and the HTTP/2 client of
fetchstops using a connection after a failed encode (encoder_poisoned).What a refused block does now
respond(),additionalHeaders(),pushStream(),request(), compatwriteHead()orwriteEarlyHints(): the session still ends withERR_HTTP2_SESSION_ERROR: Session closed with error code 9one tick later, as before. Blocks that other streams send in that tick now decode right. Node's encoder does not have this limit, so the rows that expect code 9 pin a known difference.sendTrailers():frameError, the stream ends withFRAME_SIZE_ERROR, then a graceful GOAWAY, as before.maxSendHeaderBlockLength: the check uses nghttp2's bound (12, plus 12 per field, plus the name and value bytes, plus 5) and runs before the encode.respond({ ":status": 200, "x-a": <N bytes> }): N up to 246 is sent by main, by this PR and by node v26.3.0. N from 247 to 465: main sends the response, this PR and node reset the stream. N from 466: main sends nothing and the request hangs, this PR and node reset the stream.frameErrorand resets the stream withFRAME_SIZE_ERROR. Without the reset, the 219 lengths from 247 to 465 go from a 200 to no answer.additionalHeaders()marks its block with a new argument. A refused block of that call leaves the stream open, so arespond()still goes out. node:http2: refuse a header block over the default send limit, like node #43474 has the same argument.frameError, then the stream fails withREFUSED_STREAM.ClientHttp2Session.request()is no longer reported a second time as a session'error'. Nothing reached the wire, and node reports the throw only. When the failed call also closed the session, the session is now destroyed without that error (GOAWAY code 0, it was code 2).Measurements
Release builds of main (bbdc5a5) and of this PR's source on that base (b9d682a), linux x64, same toolchain. The container has no
perf,valgrind,strace,ltraceorbloaty. The counts come from gdb: breakpoints on the allocator entry points and single steps, counted only inside the host function, in steady state.mi_malloccalls, bytes requested):respond(), 7 fields: 19 (17,136 B) on main, 18 (752 B) on the PRrequest(): 27 (17,328 B), then 26 (944 B)sendTrailers(), 2 fields: 7 (16,688 B), then 6 (304 B)pushStream(), 5 fields: 15 and 1mi_realloc, then 14 and 0additionalHeaders(): 9, then 8respond(), 7 fields: 27,947, then 28,333 (+1.4%)request(): 33,361, then 33,748 (+1.2%)sendTrailers(): 14,183, then 14,105 (-0.5%)pushStream(): 21,602, then 21,522 (-0.4%).text: 65,382,485 B, then 65,385,557 B (+3,072 B). The stripped binary: 88,864,328 B, then 88,868,424 B (+4,096 B, one page).lshpack_wrapper_*symbols: 2, then 3.size_of::<H2HeaderScratch>()is 72, soRareDatagrows by 72 B.H2FrameParserhas no new field (1,496 B). The slot keeps a scratch only when each of its two buffers is at most 64 KiB.Tests
test/js/node/http2/node-http2-header-block.test.tsis new and has 62 rows. 59 fail on bun 1.4.3-canary.1. The rows have a file of their own because they share no helper withnode-http2.test.js, which has 7,700 lines.additionalHeaders,respond, compatres.end, server and clientsendTrailers,pushStreamand clientrequest. Each row makes a clean request, the failing call, and a clean request on the same session. One row queues the failing request before'connect'. One row closes the session from inside the failingrequest(). That row fails when the new line in thecatchofrequest()is deleted. The invalid-name rows ofrespond()and of clientrequest()pass on main. Two kinds (undefined,null) are values that node accepts. node:http2: normalize outbound header objects like node #41614 changes those.sendTrailers().maxSendHeaderBlockLength(the follow-up requests are made in the same tick), the client'smaxSessionMemory, an option that is out of range, and arespond()whose options getter throws.request(). One row reads raw frames: a field of exactly 65536 bytes is sent, and the next block is indexed against it. That row passes on main. The sessions raisemaxSendHeaderBlockLength, as grpc-js does, so the rows keep their meaning when a default send limit lands.additionalHeaders()block leaves the stream open. Node v26.3.0 gives the same result for the same script.Also run on a debug build of the PR: all of
test/js/node/http2/(658 pass, 6 skip with--timeout 120000) and node's 256test-http2-*.jsfiles.Not in this PR
encode_blockto take every field, with a literal without indexing for a field that lshpack refuses.setImmediateafter a frame error. This PR resets a refused response at once, never resets a refusedadditionalHeaders()block, and does not close the session. node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440 has node's rule.res.end()in the same tick still writes one empty DATA frame for the stream. Main writes that frame with no HEADERS before it.maxSendHeaderBlockLengthis still not applied tosendTrailers()andpushStream().sendTrailers()block still ends the other streams of the session early, withEND_STREAM. The table stays in sync. Main has the same bug._write, and what that JS writes is lost. A header block that is lost that way leaves its fields in the table. An earlier version of this PR kept those frames with a loop incork(). The self-review found that the loop has no bound and that the kept frames can go out of order, so that change is out again. node:http2: remove unsafe from H2FrameParser #40240 has a hand-over that runs no JS, and tls: consolidate the open TLS fixes (both engines, node:tls, node:https, WebSocket, SQL) #44618 has thewrite()routing for a socket on top of a JSDuplex.request()orpushStream()that a header value'stoString()makes from inside anotherrequest()still reaches the wire with the higher stream id first. node:http2: coerce outbound header values before taking a stream id #37568 works on that.SETTINGS_HEADER_TABLE_SIZE(Bun's HTTP/2 server potentially ignores Nginx's HPACK for header compression settingSETTINGS_HEADER_TABLE_SIZE=0#19152, node:http2: apply SETTINGS_HEADER_TABLE_SIZE to the HPACK encoder and decoder #43345). After this PR that update has one place to go:encode_header_list.fetch(src/http/h2_client/) keeps its per-field encode. After a failed encode it opens no new stream on that connection (encoder_poisoned), so no later block uses the shifted table.src/runtime/api/bun/h2/uses the block encode too:send_header_block()andsend_push_promise()take the staged block. Nothing calls that outbound half yet. Its unit tests are compile-checked only (cargo check -p bun_runtime --lib --tests).Related PRs and landing order
HeaderListand then still encode field by field. After this PR they can useHeaderBlock::deflate_bound()and drop that type. node:http2: reset the stream when a response header block is over maxSendHeaderBlockLength #43523 also resets a refused response. This PR has the reset of node:http2: reset the stream when respond() exceeds maxSendHeaderBlockLength #43440 and the informational argument of node:http2: refuse a header block over the default send limit, like node #43474, because the moved check needs them. A maintainer has to set the order: the PR that lands second needs a rebase.Self-review
32 concerns raised, 28 addressed in the code, the tests or this text. Not taken:
SETTINGS_HEADER_TABLE_SIZEhere. That is the subject of node:http2: apply SETTINGS_HEADER_TABLE_SIZE to the HPACK encoder and decoder #43345.Rows for
respondWithFile()and for a raw header array that throws were suggested and are not added.History
The first version of this PR (September, 90f334a) fixed the validation case only. Its text said that an encoder failure needs no pre-check, because it ends the session. That statement was not tested and it is wrong: see the second way above. A later version (57fa470) also changed
cork()andwrite(). Those changes are out again, see "Not in this PR".[human-review] gate passed · iteration 0 · 3 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
root cause · written by the author bot
The header-serialization paths for
request,sendTrailers,additionalHeaders, andpushStreamvalidated and HPACK-encoded each field one at a time, so when a later field failed validation the call threw after earlier fields had already been inserted into the connection's shared dynamic table without ever being sent, leaving the peer's decoder out of sync and causing later header blocks to decode onto the wrong stream or trigger a COMPRESSION_ERROR GOAWAY. The fix collects and validates the complete header list into a private buffer before the encoder is touched, then performs a single…